Skip to content

fix: return real constructor on drafts (fixes lodash.isEmpty crash), keep __proto__ pollution guard#1269

Open
jonatankruszewski wants to merge 1 commit into
immerjs:mainfrom
jonatankruszewski:jony-demo
Open

fix: return real constructor on drafts (fixes lodash.isEmpty crash), keep __proto__ pollution guard#1269
jonatankruszewski wants to merge 1 commit into
immerjs:mainfrom
jonatankruszewski:jony-demo

Conversation

@jonatankruszewski

@jonatankruszewski jonatankruszewski commented Jul 9, 2026

Copy link
Copy Markdown

Fixes #1268

Problem

The prototype-pollution hardening in #1259 (48fc378) wraps constructor reads on a draft in a Proxy. Because draft.constructor is the real Object, whose prototype is a non-configurable, non-writable data property, reading draft.constructor.prototype violates a Proxy invariant and throws:

TypeError: 'get' on proxy: property 'prototype' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value

This crashes any ecosystem code that inspects value.constructor.prototype on a draft — most notably lodash.isEmpty() inside a Redux Toolkit reducer.

Why the constructor wrapper can't work

On a draft, draft.constructor is the real Object and draft.constructor.prototype is the real Object.prototype, so draft.constructor.prototype.x = 1 is literally Object.prototype.x = 1 — plain JavaScript reachable in any reducer with or without a draft. Immer is not the vector, and cannot intercept it without returning a fake constructor (which breaks constructor === Object). The wrapper's only effect on that vector was to crash. No version ever actually blocked it — 11.1.8 pollutes, 11.1.9+ throws, plain JS pollutes.

Change

  • Keep the __proto__ protection — but as a small dedicated wrapper (protoGuardTraps). __proto__ resolves to a plain object, so it can be virtualized without hitting the invariant, and it still cleanly blocks draft.__proto__.x = 1.
  • Drop the constructor wrapper — return the real constructor. Fixes the crash, restores constructor === Object, and stops new Proxy(...) from throwing on drafts that carry a non-object constructor data key.
  • Drop the has/set reserved-key blocks — restore 'constructor' in draft and stop silently dropping writes to own data keys named constructor/prototype.

setPrototypeOf and the patch-layer guards are untouched, so this is strictly safer than 11.1.8 (it adds the __proto__ block) with the crash removed.

Tests

The three tests added in #1259 wrapped each attack in try/catch and asserted only that Object.prototype stayed clean, so a crash and a real block passed identically (which is how the regression reached a release). Replaced them with tests that distinguish the two:

  • constructor / constructor.prototype reads don't throw and === Object;
  • own data keys named like reserved properties round-trip;
  • Object.setPrototypeOf(draft, …) throws;
  • draft.__proto__.x = 1 does not pollute (asserts blocked, not threw);
  • unit tests for the __proto__ guard wrapper.

All existing tests pass.

…llution guard

The 11.1.9 hardening (48fc378) wrapped both constructor and __proto__ reads in a
sanitizing Proxy. The __proto__ half cleanly blocks draft.__proto__.x = 1 from
reaching Object.prototype and is kept. The constructor half cannot work:
constructor resolves to a function whose prototype is a non-configurable,
non-writable data property, so a wrapping Proxy that hides it violates a Proxy
invariant and throws (TypeError: 'get' on proxy: property 'prototype' ...),
crashing ecosystem inspection such as lodash.isEmpty(draft). Mutating
draft.constructor.prototype is plain-JS Object.prototype mutation immer is not a
vector for and cannot intercept without breaking draft.constructor === Object.

Keep the __proto__ wrapper; drop the constructor wrapper and the has/set
reserved-key blocks. Replace the added tests with ones that distinguish a clean
block from a crash. All 3697 tests pass.
@jonatankruszewski

jonatankruszewski commented Jul 9, 2026

Copy link
Copy Markdown
Author

This also resolves #1266 (unstable draft.constructor identity breaking fast-deep-equal), and does it more completely than #1267.

#1267 caches the wrapper so draft.a.constructor === draft.b.constructor is stable again — but a cached wrapper is still a Proxy over Object, so reading wrapper.prototype still hits the invariant and throws. The lodash crash from #1268 would remain:

wrap(Object) === wrap(Object)  // true  (identity fixed by caching)
wrap(Object).prototype         // TypeError: 'get' on proxy: property 'prototype' ...

Returning the real constructor (this PR) fixes both at once: the real Object has stable identity for free (Object === Object), and .prototype reads work because there's no wrapping Proxy to violate the invariant. So this supersedes #1267 and closes #1266 as well.

@jonatankruszewski

jonatankruszewski commented Jul 9, 2026

Copy link
Copy Markdown
Author

This also fixes #1265. It's the same regression reached through a different lodash path: there _.isEqual(draft, …) goes baseKeysisPrototype, which reads value.constructor.prototype — the exact access the constructor wrapper crashes on. Same error, same commit, same fix.

Verified against that issue's repro on this branch:

const {produce} = require("immer")
const _ = require("lodash") // 4.18.1

produce(
  [
    {title: "Learn Macroeconomics", done: false},
    {title: "Try Immer", done: false}
  ],
  draft => {
    draft.push({title: "Tweet about it"})
    draft[1].done = true
    _.isEqual(draft, [
      {title: "Learn Macroeconomics", done: false},
      {title: "Try Immer", done: true},
      {title: "Tweet about it"}
    ]) // no longer throws
  }
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lodash.isEmpty() on a draft throws "TypeError: 'get' on proxy: property 'prototype'..." since 11.1.9

1 participant